AI 대화 기록의 개인정보 보존 정책
AI 대화 기록의 개인정보 보존 정책
AI 대화에는 이름과 연락처처럼 명시적인 개인정보뿐 아니라 업무 문서, 인증 정보, 건강·재무 정보, 모델이 추론한 속성까지 섞일 수 있다. “운영 개선을 위해 로그를 남긴다”는 한 문장으로 원문을 무기한 보관하면 목적과 책임이 불명확해진다. 대화 원문, 안전 감사 기록, 품질 평가 데이터, 익명화된 집계를 분리하고 각각의 목적·접근권한·보존기간·삭제 방식을 정의해야 한다.
이 글은 개발자가 시스템을 설계할 때 고려할 기술적 관점을 정리한 것이며 법률 자문이 아니다. 실제 서비스의 적용 법령, 동의·고지, 법정 보존 의무, 국외 이전 여부는 담당 법무·개인정보 보호책임자와 최신 공식 자료를 기준으로 확인해야 한다.
목차
- #AI 대화는 평범한 애플리케이션 로그가 아니다
- #먼저 데이터 흐름을 그린다
- #대화 원문을 목적별 데이터로 분리하기
- #보존 기간은 하나의 숫자가 아니다
- #수집 전에 줄이고 저장 전에 분리하기
- #마스킹과 가명처리와 익명화를 구분하기
- #접근 권한과 조회 기록 설계
- #삭제 요청을 데이터 계보 전체로 전파하기
- #백업과 검색 인덱스와 모델 학습 데이터 삭제
- #외부 모델 공급자와 처리 경계
- #예시 코드로 보존 정책 실행하기
- #품질 개선과 개인정보 최소화 함께 달성하기
- #운영 지표와 점검표
- #결론
- #참고 자료
- #관련 노트
AI 대화는 평범한 애플리케이션 로그가 아니다
일반적인 HTTP 접근 로그는 경로, 상태 코드, 처리 시간 정도를 담는다. AI 대화 원문은 사용자가 자유롭게 입력한 내용과 검색된 내부 문서, 도구 실행 결과, 모델의 답변이 한곳에 모인다.
사용자 입력
+ 첨부 문서
+ 검색된 사내 자료
+ 시스템 프롬프트
+ 도구 인자와 결과
+ 모델 답변
+ 품질 평가와 운영자 메모
사용자가 고객 문의를 요약해 달라고 하면서 이름, 전화번호, 주문번호를 붙여 넣을 수 있다. 개발자가 오류를 재현하려고 전체 프롬프트를 로그에 남기면 원래 업무 시스템보다 더 넓은 개인정보 사본이 생긴다. 검색 청크에는 사용자가 직접 입력하지 않은 다른 사람의 정보가 들어올 수도 있다.
다음 값은 모두 위험을 만들 수 있다.
- 이름, 이메일, 전화번호, 주소 같은 직접 식별자
- 주문번호, 기기 ID, IP 주소처럼 다른 데이터와 결합해 식별 가능한 값
- 건강, 금융, 인사평가와 같은 민감한 업무 내용
- API 키, 세션 토큰, 비밀번호 같은 비밀값
- 사용자의 질문에서 모델이 추론한 직업, 관심사, 상태
- 내부 문서와 도구 결과에 포함된 제3자의 정보
따라서 conversation_logs 테이블에 모든 것을 JSON으로 넣고 “나중에 필요하면 삭제”하는 방식은 위험하다. 무엇이 어디에 복제되었는지 알 수 없고, 접근 권한과 보존 기간을 목적별로 다르게 적용하기 어렵다.
먼저 데이터 흐름을 그린다
보존 기간을 정하기 전에 수집부터 파기까지의 흐름을 그린다. 애플리케이션 데이터베이스만 살펴보면 메시지 큐, 관측 플랫폼, 검색 인덱스, 백업, 평가셋으로 복제된 데이터를 놓친다.
flowchart LR
U[사용자 입력] --> G[API Gateway]
G --> A[AI Orchestrator]
A --> P[Model Provider]
A --> T[Tool Services]
A --> C[Conversation Store]
A --> O[Logs and Traces]
C --> I[Search Index]
C --> E[Quality Evaluation]
C --> B[Backup]
E --> D[Curated Dataset]각 화살표에 다음 질문을 붙인다.
- 어떤 데이터 항목이 이동하는가
- 처리 목적은 무엇인가
- 원문이 꼭 필요한가, 식별자를 제거할 수 있는가
- 누가 접근할 수 있는가
- 어느 지역과 어느 사업자의 시스템에 저장되는가
- 언제 삭제되고 하위 복제본에도 어떻게 전파되는가
개인정보보호위원회의 생성형 AI 안내서도 AI 수명주기 단계별로 목적 설정, 데이터 처리 근거, 개인정보 보호 중심 설계, 접근 통제, 입출력 필터링, 권리 보장과 거버넌스를 함께 고려하도록 안내한다. 단순히 채팅 테이블의 TTL만 정해서 끝낼 문제가 아니다.
대화 원문을 목적별 데이터로 분리하기
같은 요청에서 만들어져도 사용 목적이 다르면 별도 데이터 제품으로 취급한다.
| 데이터 | 목적 | 원문 필요성 | 보존 예시 |
|---|---|---|---|
| 사용자 대화 기록 | 사용자가 이전 대화를 다시 보기 | 사용자 설정에 따름 | 계정 정책 또는 직접 삭제 |
| 일시 처리 버퍼 | 요청 수행과 스트리밍 | 처리 중에만 필요 | 완료 후 즉시 또는 짧은 TTL |
| 오류 진단 샘플 | 장애 재현 | 제한된 사례만 필요 | 짧은 기간, 엄격한 접근 |
| 안전 감사 이벤트 | 정책 위반 조사 | 전체 원문보다 분류·해시 중심 | 위험과 의무에 따른 기간 |
| 품질 평가셋 | 회귀 테스트 | 선별·마스킹한 최소 구간 | 데이터셋 버전별 검토 |
| 비용·성능 메트릭 | 운영 최적화 | 원문 불필요 | 집계값 장기 보존 가능 |
| 법적 보존 대상 | 분쟁·법정 의무 | 지정된 항목만 | 근거별 기간, 별도 격리 |
표의 기간은 서비스 정책을 대신하는 정답이 아니다. 중요한 점은 raw_messages: 30d처럼 하나의 전역 숫자를 정하기 전에 목적과 데이터 종류를 분리하는 것이다.
예를 들어 토큰 비용 분석에는 사용자 원문이 필요하지 않다.
{
"feature": "support-answer",
"inputTokens": 820,
"outputTokens": 210,
"latencyMs": 1840,
"outcome": "completed"
}
오류 분석에도 전체 대화 대신 오류 코드, 프롬프트 버전, 모델 별칭, 스키마 검증 결과로 먼저 조사할 수 있다. 꼭 원문이 필요한 사례만 제한된 진단 저장소로 승격한다.
보존 기간은 하나의 숫자가 아니다
보존 정책에는 기간뿐 아니라 시작점과 파기 조건이 필요하다.
retention_rule:
data_class: diagnostic_sample
purpose: reproduce_model_failures
starts_at: captured_at
duration: 14d
delete_when:
- ttl_expired
- user_erasure_confirmed
- incident_closed
legal_hold_overrides: true
review_owner: privacy-engineering
30일 보관이라고만 쓰면 다음이 모호하다.
- 대화 생성일부터 30일인가, 계정 해지일부터 30일인가
- 사용자가 직접 저장한 대화도 포함하는가
- 파생된 임베딩과 평가셋도 함께 삭제되는가
- 백업에서는 언제 사라지는가
- 법적 보존 사유가 있으면 누가 예외를 승인하는가
정책 엔진은 보존 기한과 현재 상태를 함께 평가한다.
interface RetentionDecision {
action: "keep" | "delete" | "quarantine" | "review";
reason: string;
executeAfter?: string;
policyVersion: string;
}
법령이나 계약 때문에 일부 항목을 더 오래 보존해야 한다면 전체 대화를 그대로 남기기보다 필요한 최소 항목을 분리 보관한다. 목적을 달성한 데이터와 법적 예외 데이터를 같은 테이블에 두면 일반 조회 경로에서 계속 사용될 수 있다.
“혹시 필요할 것 같아서”는 보존 목적이 아니다. 각 데이터의 목적, 필요한 최소 기간, 책임자, 재검토 일자를 문서화한다.
수집 전에 줄이고 저장 전에 분리하기
가장 안전한 데이터는 수집하지 않은 데이터다. 입력창 아래에 “개인정보를 넣지 마세요”라고 적는 것만으로는 충분하지 않다. 업무 특성상 사용자가 정보를 입력할 수밖에 없을 수도 있다.
수집 전과 저장 전의 방어를 나눈다.
- UI에서 민감정보 입력 가능성을 설명한다.
- 기능에 불필요한 첨부 유형과 필드를 받지 않는다.
- 도구 응답에서 모델에 필요한 필드만 선택한다.
- 원문을 로그로 보내기 전에 비밀값과 직접 식별자를 탐지한다.
- 대화 저장소와 관측 저장소를 분리한다.
- 검색 인덱스에는 접근 범위와 삭제 키를 함께 저장한다.
interface SafeToolResult {
orderStatus: "paid" | "shipped" | "cancelled";
expectedDeliveryDate?: string;
}
function projectOrderForModel(order: InternalOrder): SafeToolResult {
return {
orderStatus: order.status,
expectedDeliveryDate: order.delivery?.expectedDate,
};
}
모델이 배송 상태를 답하는 데 고객의 전체 주소, 결제 수단, 상담 이력이 필요하지 않다면 처음부터 넘기지 않는다. 사후 마스킹보다 필드 수준 projection이 안정적이다.
마스킹과 가명처리와 익명화를 구분하기
세 방법은 같은 의미가 아니다.
| 방법 | 예시 | 원래 사람과 다시 연결 가능성 | 주된 용도 |
|---|---|---|---|
| 표시 마스킹 | 홍*동, 010-****-1234 |
원본 저장소를 통해 가능 | 화면 노출 최소화 |
| 토큰화 | 이메일을 임의 ID로 치환 | 매핑 테이블이 있으면 가능 | 동일인 이벤트 연결 |
| 가명처리 | 추가 정보 없이는 특정인을 알아보기 어렵게 처리 | 별도 정보로 재결합 가능 | 제한된 분석 |
| 익명화 | 합리적으로 재식별하기 어려운 통계로 변환 | 목표상 불가능 | 집계 분석 |
문자열에서 이름과 이메일을 지웠다고 자동으로 익명 데이터가 되지는 않는다. 희귀한 직책, 사건 날짜, 회사명 조합만으로 사람을 알아볼 수도 있다. 대화는 자유 텍스트라 재식별 위험 평가가 특히 어렵다.
interface RedactionResult {
redactedText: string;
detectedTypes: Array<
"email" | "phone" | "secret" | "account" | "freeform-sensitive"
>;
requiresReview: boolean;
}
function redactForDiagnostics(message: string): RedactionResult {
const secretSafe = secretScanner.replace(message, "[SECRET]");
const piiSafe = piiDetector.replace(secretSafe, {
email: "[EMAIL]",
phone: "[PHONE]",
account: "[ACCOUNT]",
});
return {
redactedText: piiSafe.text,
detectedTypes: piiSafe.types,
requiresReview: piiSafe.lowConfidenceMatch,
};
}
탐지기는 완벽하지 않으므로 마스킹 통과 = 개인정보 없음으로 해석하지 않는다. 고위험 데이터셋으로 승격할 때 사람 검토와 표본 감사를 둘 수 있다.
접근 권한과 조회 기록 설계
운영자라 해도 모든 대화 원문을 기본적으로 볼 필요는 없다. 역할과 목적에 맞게 접근을 나눈다.
flowchart TD
R[조회 요청] --> A{역할 확인}
A -->|지원 담당자| S[담당 티켓 범위]
A -->|품질 분석가| Q[마스킹된 표본]
A -->|보안 조사자| I[승인된 사건 범위]
A -->|개발자| M[메타데이터 우선]
S --> L[접근 로그]
Q --> L
I --> L
M --> L권한 검사에는 테넌트, 대화 소유자, 업무 목적, 사건 번호, 접근 만료 시각을 포함할 수 있다. 긴급 접근은 사후 검토가 가능한 별도 흐름으로 둔다.
async function readConversation(
actor: Actor,
conversationId: string,
purpose: AccessPurpose,
) {
const conversation = await repository.metadata(conversationId);
await policy.assertCanRead(actor, conversation, purpose);
await accessAudit.append({
actorId: actor.id,
conversationId,
purpose,
occurredAt: new Date().toISOString(),
});
return repository.read(conversationId);
}
접근 로그 자체에도 사용자와 대화 식별자가 들어간다. 무기한 공개 메트릭으로 보내지 말고 별도 보존·접근 정책을 적용한다.
삭제 요청을 데이터 계보 전체로 전파하기
메시지 테이블 한 행을 삭제하는 것으로 끝나지 않는다.
conversation msg-42
-> trace trace-81
-> search chunk chunk-19
-> embedding vector vec-19
-> evaluation case eval-7
-> export file export-3
-> backup snapshot backup-2026-06-23
각 파생 데이터가 원본 메시지 ID나 삭제 가능한 주체 ID를 가지고 있어야 한다. 식별자를 완전히 제거한 집계는 삭제 대상에서 제외될 수 있지만, 정말 재식별이 불가능한지는 별도 판단이 필요하다.
삭제는 영속 워크플로우로 관리한다.
stateDiagram-v2
[*] --> Requested
Requested --> Verified: 요청자 확인
Verified --> Planned: 대상 계보 계산
Planned --> Deleting
Deleting --> WaitingBackup: 운영 저장소 완료
Deleting --> PartialFailure: 일부 저장소 실패
PartialFailure --> Deleting: 재시도
WaitingBackup --> Completed: 백업 만료 정책 확인
Completed --> [*]interface ErasureTask {
id: string;
subjectId: string;
requestedAt: string;
policyVersion: string;
targets: Array<{
system: string;
locator: string;
state: "pending" | "deleted" | "not_found" | "failed";
}>;
}
not_found도 성공적으로 처리할 수 있지만 조회 오류와 구분한다. 전체 완료 전에 사용자에게 삭제가 끝났다고 알리지 않는다. 법적 보존 예외가 있다면 숨기지 말고 지정된 정책에 따라 격리하고 일반 처리에서는 제외한다.
백업과 검색 인덱스와 모델 학습 데이터 삭제
백업에서 특정 행을 즉시 제거하기 어렵다는 이유로 정책을 비워 두면 안 된다. 백업은 접근이 제한되고 덮어쓰기나 만료 주기가 명확해야 한다. 복구할 때 삭제 목록을 다시 적용하는 절차도 필요하다.
운영 DB 삭제
-> tombstone ledger 기록
-> 검색 인덱스와 캐시 삭제
-> 백업은 정해진 주기로 만료
-> 백업 복구 시 tombstone 재적용
벡터 데이터베이스에서는 원문을 지워도 임베딩과 청크 메타데이터가 남을 수 있다. sourceMessageId를 기준으로 둘 다 삭제하고 인덱스의 최종 일관성을 확인한다.
평가셋이나 미세조정 데이터로 복사된 뒤에는 삭제가 더 어려워진다. 원본 대화를 자동으로 학습 데이터로 보내지 말고, 명시적인 선별·마스킹·승인 단계를 둔다. 데이터셋에는 source lineage와 정책 버전을 기록해야 한다.
이미 모델 학습에 반영된 데이터의 제거 가능성과 절차는 사용하는 기술과 계약에 따라 달라진다. “원본 행 삭제”와 “학습된 모델에서 영향 제거”를 같은 작업이라고 약속하지 말고 범위를 명확히 설명해야 한다.
외부 모델 공급자와 처리 경계
외부 API에 프롬프트를 보내는 순간 데이터 흐름은 자사 저장소 밖으로 확장된다. 다음을 계약과 설정에서 확인한다.
- 입력과 출력이 서비스 제공 외 목적으로 사용되는가
- 공급자의 로그 보존 기간과 제로 보존 옵션은 무엇인가
- 데이터가 처리·저장되는 지역은 어디인가
- 하위 처리자가 있는가
- 삭제 요청과 보안 사고 통지 절차는 무엇인가
- 프로젝트나 테넌트별 보존 설정이 실제 요청에 적용되는가
공급자 이름만 보고 정책을 추정하지 않는다. 같은 공급자라도 소비자 제품, 기업용 제품, API, 특정 기능의 처리 조건이 다를 수 있다. 설정 스크린샷만으로 끝내지 말고 실제 배포 구성과 계약 문서를 연결한다.
interface ProviderDataPolicy {
provider: string;
accountScope: string;
trainingUse: "disabled" | "enabled" | "contractual-exception";
retentionMode: "standard" | "zero-retention" | "custom";
processingRegions: string[];
reviewedAt: string;
evidenceDocumentId: string;
}
예시 코드로 보존 정책 실행하기
정책은 위키 문서에만 있으면 실제 파기를 보장하지 못한다. 기계가 읽을 수 있는 규칙과 실행 작업을 연결한다.
type DataClass =
| "conversation"
| "diagnostic_sample"
| "safety_audit"
| "quality_dataset"
| "usage_metric";
interface RetentionRule {
dataClass: DataClass;
durationDays: number | null;
deleteOnUserRequest: boolean;
legalHoldAllowed: boolean;
policyVersion: string;
}
const rules: RetentionRule[] = [
{
dataClass: "diagnostic_sample",
durationDays: 14,
deleteOnUserRequest: true,
legalHoldAllowed: true,
policyVersion: "privacy-2026-06",
},
{
dataClass: "usage_metric",
durationDays: 400,
deleteOnUserRequest: false,
legalHoldAllowed: false,
policyVersion: "privacy-2026-06",
},
];
이 숫자는 예시이며 실제 법적 기간을 의미하지 않는다. 집계 메트릭이 정말 개인과 연결되지 않을 때만 삭제 요청과 분리할 수 있다.
async function sweepExpiredRecords(now: Date) {
const candidates = await repository.findExpired(now, { limit: 500 });
for (const record of candidates) {
const decision = await retentionPolicy.evaluate(record, now);
if (decision.action !== "delete") continue;
await deletionQueue.enqueue({
deletionId: stableDeletionId(record.id, decision.policyVersion),
rootRecordId: record.id,
policyVersion: decision.policyVersion,
reason: decision.reason,
});
}
}
삭제 작업은 멱등적으로 만든다.
async function deleteConversationGraph(command: DeleteCommand) {
const nodes = await lineage.findDescendants(command.rootRecordId);
for (const node of nodes) {
const result = await adapters
.forSystem(node.system)
.delete(node.locator, {
idempotencyKey: command.deletionId + ":" + node.id,
});
await deletionAudit.record({
deletionId: command.deletionId,
nodeId: node.id,
result,
occurredAt: new Date().toISOString(),
});
}
await deletionAudit.assertAllTerminal(command.deletionId);
}
삭제 감사 로그에는 삭제된 원문을 다시 복사하지 않는다. 대상 ID, 시스템, 정책 버전, 결과 코드처럼 완료를 증명하는 최소 메타데이터만 남긴다.
품질 개선과 개인정보 최소화 함께 달성하기
“품질을 개선하려면 모든 대화를 영원히 보관해야 한다”는 전제부터 검토해야 한다. 많은 분석은 원문 없이도 가능하다.
| 개선 목적 | 원문 대신 사용할 수 있는 데이터 |
|---|---|
| 비용 최적화 | 기능, 토큰, 지연, 결과 상태 |
| 검색 품질 | 문서 ID, 순위, 클릭·채택 여부 |
| 스키마 오류 | 오류 코드, 필드 경로, 프롬프트 버전 |
| 회귀 평가 | 사람이 선별하고 마스킹한 대표 사례 |
| 사용자 만족도 | 명시적 평가와 집계된 행동 신호 |
실패 사례를 평가셋으로 승격할 때 자동 복사하지 않는다.
flowchart LR
F[운영 실패] --> M[자동 마스킹]
M --> R[권한 있는 사람 검토]
R -->|승인| C[최소 재현 케이스]
R -->|거절| X[만료 후 삭제]
C --> V[버전이 있는 평가셋]
V --> P[주기적 재검토와 파기]평가에 필요한 의미만 남기고 실제 이름, 회사, 주문번호는 가상 값으로 교체한다. LLM 평가셋을 실제 실패 사례로 만드는 방법의 재현성 원칙과 개인정보 최소화 원칙을 함께 적용한다.
운영 지표와 점검표
보존 정책의 존재보다 실제 실행 여부를 측정한다.
| 지표 | 확인할 문제 |
|---|---|
| expired records pending | 만료됐지만 삭제되지 않은 데이터 |
| deletion completion time | 요청부터 전체 전파 완료까지 시간 |
| deletion partial failure | 특정 저장소에서 반복 실패하는 비율 |
| orphan index rate | 원본 없이 남은 검색 청크·벡터 |
| unclassified record rate | 데이터 등급이나 목적이 없는 레코드 |
| privileged access count | 원문에 대한 고권한 조회량 |
| diagnostic promotion rate | 운영 원문이 평가 데이터로 승격되는 비율 |
| backup replay compliance | 복구 시 삭제 목록이 재적용됐는지 |
결론
AI 대화 기록은 단순한 디버그 로그가 아니다. 사용자 입력, 내부 검색 자료, 도구 결과, 모델이 만든 추론이 합쳐진 고밀도 데이터다. 원문 하나를 여러 목적으로 복사하면 접근 범위와 삭제 책임이 빠르게 불명확해진다.
핵심은 대화를 목적별 데이터로 분리하고, 각 데이터의 수집 최소화·접근·보존·삭제를 하나의 수명주기로 관리하는 것이다. 삭제는 메시지 행 하나가 아니라 검색 인덱스, 캐시, 평가셋, 내보내기와 백업까지 이어지는 데이터 계보의 작업이어야 한다.
좋은 보존 정책은 “며칠 저장한다”는 표에서 끝나지 않는다. 왜 필요한지 설명할 수 있고, 기간이 끝났을 때 실제로 사라지며, 예외가 있다면 일반 사용에서 격리되고, 그 모든 과정을 감사할 수 있어야 한다.
참고 자료
- 개인정보보호위원회, 생성형 인공지능(AI) 개발·활용을 위한 개인정보 처리 안내서(2025.8.)
- 개인정보보호위원회, 개인정보의 안전성 확보조치 기준 안내서(2025.11.) 등 현행 안내서 목록
- 국가법령정보센터, 개인정보보호위원회 개인정보보호지침